<?php
/**
 * Created by PhpStorm.
 * User: lhl
 * Date: 2020/12/4
 * Time: 13:04
 * Features: 分库分表基础模型抽象类
 */

namespace app\common\model\depots;


use app\common\exception\CustomException;
use app\common\service\StringHelper;
use think\exception\PDOException;
use think\facade\Cache;
use think\facade\Config;
use think\facade\Env;
use think\Model;
use think\Db;

abstract class DepotsBaseModel extends Model
{
    //单库的数据表数量 后续不能修改！！
    const DATABASE_TABLE_LIMIT = 2000;

    //创建新子库后,通知后台刷新主库中的数据库列表数据的缓存key名称
    const IS_NEED_ADMIN_REFRESH_DATABASE_LIST_KEY = 'is_need_admin_refresh_database_list_key';

    //子库配置文件名称
    const CHILD_DATABASE_CONFIG_FILE = 'child_database.php';

    //子库配置文件主模板
    const CHILD_DATABASE_CONFIG_TPL = 'child_database.tpl';

    //子库配置文件次模板
    const CHILD_DATABASE_CONFIG_ITEM_TPL = 'child_database_item.tpl';

    //创建子库时的数据库连接标识,避免重复创建数据库连接资源
    const CHILD_DATABASE_CREATE_KEY = 'child_database_create_key';

    //创建子库时,初始化创建其他数据表时的方法名称
    const CHILD_DATABASE_CREATE_OTHER_TABLES_METHOD = 'initCreateOtherTables';

    //MySQL要求版本号
    const MYSQL_VERSION_LIMIT = '5.7.9';

    //本表是否根据host_id进行了水平分表 ,若子类不需要分表,则重写此属性为false
    protected $isSeparationTable = true;

    //此表的子数据库的标识的固定前缀,在子类中需要重写此属性
    protected $databaseKeyPrefix;

    //此表的子数据库的名称的固定前缀,在子类中需要重写此属性
    protected $databaseNamePrefix;

    //操作数据库的标识
    protected $connection ;

    //主机id
    protected $hostId;

    //表前缀
    protected $tablePrefix;

    //是否已经拼接过了表前缀,不要手动设置此值
    protected $isJoinTablePrefix = false;

    //本次创建数据库的配置,不要手动设置此值,用于创库时自动创建其他数据表
    protected $thisConfig;

    /**
     * 构造函数：自动将表名与模型名保持一致
     */
    public function __construct($data = [])
    {
        parent::__construct($data);
        $class = substr(strrchr(get_class($this),'\\'),1);
        $classSuffix = Config::get('app.class_suffix',false);
        if($classSuffix === true){
            $classSuffix = 'Model';
        }
        $class = trim($class,$classSuffix);
        //默认表名 (不含表前缀)
        $this->table = ltrim(StringHelper::humpToLine($class),'_');
        //默认水平分表表前缀 (不含表前缀)
        $this->tablePrefix = $this->table;
    }

    /**
     * 根据主机id设置数据库名/表名
     * @param  int      $hostId 主机id
     *
     * @return string   数据库标识
     */
     final public function setHostId($hostId){
        $this->hostId = $hostId;

        //检测子库配置是否正常,获得热配置数据
        $config = $this->checkDatabaseConfig($hostId,true);

        //切库 使用热数据配置连接数据库,为了解决数据库配置文件的热加载问题
        $this->connection = $config;

        //确定表前缀
        $tablePrefix = $config['prefix'];

        //切库后固定添加表前缀 = 前缀 + 默认表名
        if(!$this->isJoinTablePrefix){
            $this->tablePrefix = $tablePrefix . $this->tablePrefix;
            $this->isJoinTablePrefix = true;
        }
        //切表
        $this->table = $this->getFullTableName($hostId);
        //返回数据库连接标识,用于分布式事务
        return $this->getDataBaseKey($hostId);
    }

    /**
     * 主动关闭当前数据库连接
    */
    final public function closeConnection()
    {
        Db::connect($this->connection)->close();
    }

    /**
     * 计算子库序号
     * @param int $hostId 主机id
     */
    final public function getDataBaseNo($hostId){
        return (int)(($hostId - 1) / self::DATABASE_TABLE_LIMIT) + 1;
    }

    /**
     * 根据主机id计算返回数据库标识(不设置)
     * @param int $hostId 主机id
     */
    final public function getDataBaseKey($hostId){
        return $this->databaseKeyPrefix . '_' .$this->getDataBaseNo($hostId);
    }

    /**
     * 根据主机id计算返回数据库标识和数据库名称以及序号(不设置)
    */
    final public function getDatabaseInfo($hostId){
        $no = $this->getDataBaseNo($hostId);
        return [
            //数据库序号
            'no' => $no,
            //数据库标识
            'key' => $this->databaseKeyPrefix . '_' .$no,
            //数据库名称
            'name' => $this->databaseNamePrefix . '_' . $no
        ];
    }

    /**
     * 根据主机id返回表名(不设置)
     * @param int $hostId 主机id
     */
    final public function getFullTableName($hostId){
        return $this->isSeparationTable ? $this->tablePrefix . '_' . $hostId : $this->tablePrefix;
    }

    /**
     * 检测子库的数据库配置文件信息是否正确
     * @param int $hostId 主机id
     *
     * @return bool|array
     */
    final public function checkDatabaseConfig($hostId,$isThrowException = true){
        $config = $this->getDatabaseConfig($hostId);
        if(!$config){
            if($isThrowException){
                throw new CustomException("主机Id为{$hostId}的子数据库尚未创建,数据库标识为{$this->getDataBaseKey($hostId)}",444);
            }else{
                return false;
            }
        }

        //验证字段
        $fields = [
            'type','hostname','database','username','password','hostport',
        ];

        foreach ($fields as $field){
            if(!isset($config[$field]) || !$config[$field]){
                if($isThrowException){
                    throw new CustomException("主机Id为{$hostId}的子数据库的配置异常,数据库标识为{$this->getDataBaseKey($hostId)}",444);
                }else{
                    return false;
                }

            }
        }
        //正确则返回热配置信息
        return $config;
    }

    /**
     * 查询子库的数据库配置信息
     * @param int $hostId 主机id
     */
    final public function getDatabaseConfig($hostId){
        //该库的数据库标识及序号
        $db = $this->getDatabaseInfo($hostId);
        //因为涉及循环操作,改为require获取数据
        $config = $this->getChildDatabaseConfigFile();
        $config = isset($config[$db['key']]) ? $config[$db['key']] : [];
        return $config;
    }

    /**
     * 使用root账号,根据主机id动态创建子数据库
     *
     * @return array
    */
    final public function createChildDatabaseByRoot($hostId){
        $config = Config::pull('root_database');
        return $this->createChildDatabase($hostId,$config['username'],$config['password']);
    }

    /**
     * 根据主机id和mysql账号信息动态创建子数据库
     * @param int    $hostId      主机id
     * @param string $username    mysql具有权限的账号名称
     * @param string $password    mysql具有权限的账号的密码
     *
     * @return array
     * [
     *      'result'        => 结果
     *      'msg'           => 信息
     *      'isNeedCreate'  => 是否真正需要进行创库操作,用于前端做弹框提示
     *      'db'            => 本次创建库的信息
     * ]
    */
    final public function createChildDatabase($hostId,$username,$password){
        //根据主机id确定子库标识和库名称
        $db = $this->getDatabaseInfo($hostId);
        //前置检测 ：
        //1.以子库的动态配置文件为标准,创建过的子库,不要重复创建,不要手动修改子库的配置文件！！
        if ($this->checkDatabaseConfig($hostId,false)) {
            return [
                'result' => true,
                'msg' => '该数据库已经创建过了',
                'isNeedCreate' => false,
                'db' => $db
            ];
        }

        //创库账号的配置
        $createConfig = [
            // 数据库类型
            'type'        => 'mysql',
            // 服务器地址 以主库为准
            'hostname'    => Config::get('database.hostname'),
            // 数据库用户名
            'username'    => $username,
            // 数据库密码
            'password'    => $password,
            // 数据库连接端口 以主库为准
            'hostport'    => Config::get('database.hostport'),
        ];

        //2. MySQL 5.7.9以上 版本号
        $sql = "SELECT VERSION() as version";
        try{
            $response = Db::connect($createConfig,self::CHILD_DATABASE_CREATE_KEY)->query($sql);
            if(!isset($response[0]['version']) || !$response[0]['version']){
                return [
                    'result' => false,
                    'msg' => '子数据库创建失败：查询MySQL版本号失败',
                    'isNeedCreate' => true,
                    'db' => $db
                ];
            }
            $result = StringHelper::versionCompare($response[0]['version'],self::MYSQL_VERSION_LIMIT);
            if($result == -1){
                return [
                    'result' => false,
                    'msg' => '子数据库创建失败：MySQL版本要求至少为' . self::MYSQL_VERSION_LIMIT . '以上',
                    'isNeedCreate' => true,
                    'db' => $db
                ];
            }

        }catch (\Exception $e){
            return [
                'result' => false,
                'msg' => '子数据库创建失败：查询MySQL版本号失败,'.$e->getMessage(),
                'isNeedCreate' => true,
                'db' => $db
            ];
        }
        //3.该数据库服务必须支持XA分布式事务
        $sql = "SHOW VARIABLES LIKE 'innodb_support_xa'";
        try{
            $response = Db::connect($createConfig,self::CHILD_DATABASE_CREATE_KEY)->query($sql);
            if(!isset($response[0]['Value']) || $response[0]['Value'] != 'ON'){
                return [
                    'result' => false,
                    'msg' => '子数据库创建失败：该数据库环境不支持XA事务',
                    'isNeedCreate' => true,
                    'db' => $db
                ];
            }
        }catch (\Exception $e){
            return [
                'result' => false,
                'msg' => '子数据库创建失败：查询是否支持XA事务失败,'.$e->getMessage(),
                'isNeedCreate' => true,
                'db' => $db
            ];
        }

        //拼接sql,创建数据库
        $sql = "CREATE DATABASE IF NOT EXISTS `{$db['name']}` default character set = 'utf8'";
        try{
            //以指定的账号连接数据库,进行创库操作
            $response = Db::connect($createConfig,self::CHILD_DATABASE_CREATE_KEY)->execute($sql);
        }catch (PDOException $e){
            $errMsg = $e->getMessage();
            if(strpos($errMsg,'Access denied') !== false){
                return [
                    'result' => false,
                    'msg' => '子数据库创建失败：账号没有权限',
                    'isNeedCreate' => true,
                    'db' => $db
                ];
            }else{
                return [
                    'result' => false,
                    'msg' => '子数据库创建失败：'.$errMsg,
                    'isNeedCreate' => true,
                    'db' => $db
                ];
            }
        } catch (\PDOException $e){
            $errMsg = $e->getMessage();
            if(strpos($errMsg,'Access denied') !== false){
                return [
                    'result' => false,
                    'msg' => '子数据库创建失败：数据库账号错误',
                    'isNeedCreate' => true,
                    'db' => $db
                ];
            }else{
                return [
                    'result' => false,
                    'msg' => '子数据库创建失败：'.$errMsg,
                    'isNeedCreate' => true,
                    'db' => $db
                ];
            }
        }


        if(!$response){
            return [
                'result' => false,
                'msg' => '子数据库创建失败',
                'isNeedCreate' => true,
                'db' => $db
            ];
        }

        //给系统业务使用的数据库账号赋权限
        $config = Config::pull('database');
        try{
            //给业务账号赋权限 若root账号没有Grant Option权限,此处会异常
            $sql = "GRANT ALL PRIVILEGES ON {$db['name']}.* TO {$config['username']}@'%' IDENTIFIED BY '{$config['password']}' WITH GRANT OPTION";
            //root 账户必须要有Grant Option权限 此处才能成功
            Db::connect($createConfig,self::CHILD_DATABASE_CREATE_KEY)->execute($sql);

            //刷新权限配置
            $sql = "flush privileges";
            Db::connect($createConfig,self::CHILD_DATABASE_CREATE_KEY)->execute($sql);

        }catch (PDOException $e){
            $errMsg = $e->getMessage();
            if(strpos($errMsg,'Access denied') !== false){
                return [
                    'result' => false,
                    'msg' => '子数据库创建失败：为子数据库账号赋权失败，请提供具有权限的账号',
                    'isNeedCreate' => true,
                    'db' => $db
                ];
            }else{
                if(strpos($errMsg,'access violation') !== false){
                    return [
                        'result' => false,
                        'msg' => '子数据库创建失败：为子数据库账号赋权失败，该账号不具有Grant Option权限',
                        'isNeedCreate' => true,
                        'db' => $db
                    ];
                }

                return [
                    'result' => false,
                    'msg' => '子数据库创建失败，为子数据库账号赋权失败：'.$errMsg,
                    'isNeedCreate' => true,
                    'db' => $db
                ];
            }

        }catch (\PDOException $e){
            $errMsg = $e->getMessage();
            if(strpos($errMsg,'Access denied') !== false){
                return [
                    'result' => false,
                    'msg' => '子数据库创建失败，为子数据库账号赋权失败：请提供具有权限的账号',
                    'isNeedCreate' => true,
                    'db' => $db
                ];
            }else{
                if(strpos($errMsg,'access violation') !== false){
                    return [
                        'result' => false,
                        'msg' => '子数据库创建失败：为子数据库账号赋权失败，该账号不具有Grant Option权限',
                        'isNeedCreate' => true,
                        'db' => $db
                    ];
                }
                return [
                    'result' => false,
                    'msg' => '子数据库创建失败，为子数据库账号赋权失败：'.$errMsg,
                    'isNeedCreate' => true,
                    'db' => $db
                ];
            }
        }

        //本库如果需要初始化创建其他业务表,则进行创建
        $otherTableMethod = self::CHILD_DATABASE_CREATE_OTHER_TABLES_METHOD;
        if(method_exists($this,$otherTableMethod)){
            //本次新的配置
            $thisConfig = $this->getThisConfig($db);
            $response = $this->$otherTableMethod($thisConfig);
            if(!$response){
                return [
                    'result' => false,
                    'msg' => '子数据库创建失败，初始化创建其他数据表失败',
                    'isNeedCreate' => true,
                    'db' => $db
                ];
            }
        }

        //根据子库模板追加生成新的子数据库配置文件
        $response = $this->putChildDatabaseConfigFile($db);
        if(!$response['result']){
            return $response;
        }

        //设置缓存,通知后台刷新数据库列表mysql中的数据
        if (!Cache::set(self::IS_NEED_ADMIN_REFRESH_DATABASE_LIST_KEY,1)) {
            return [
                'result' => true,
                'msg' => '子数据库创建成功,但缓存设置失败',
                'isNeedCreate' => true,
                'db' => $db
            ];
        }

        return [
            'result' => true,
            'msg' => '子数据库创建成功',
            'isNeedCreate' => true,
            'db' => $db
        ];
    }

    /**
     * 读取子库配置文件
    */
    final public function getChildDatabaseConfigFile(){
        $path = Env::get('config_path'). '/'.self::CHILD_DATABASE_CONFIG_FILE;
        if (is_file($path)) {
            $config = require Env::get('config_path'). '/'.self::CHILD_DATABASE_CONFIG_FILE;
        }else{
            $config = [];
        }
        return $config;
    }

    /**
     * 获取本次创建的新数据库的配置
     * @param array $db 数据库信息
     * [
     *      'no' =>   序号,
     *      'key' =>  数据库标识
     *      'name' => 数据库名称
     * ]
     *
     * @return array
    */
    final protected function getThisConfig($db){
        //新的子数据库配置
        return  [
            // 数据库类型
            'type'        => 'mysql',
            // 服务器地址   与主库保持统一*
            'hostname'    => Config::get('database.hostname'),
            // 数据库名     子库特有的数据库名称
            'database'    => "{$db['name']}",
            // 数据库用户名 与主库保持统一
            'username'    => Config::get('database.username'),
            // 数据库密码  与主库保持统一
            'password'    => Config::get('database.password'),
            // 端口        与主库保持统一
            'hostport'    => Config::get('database.hostport'),
            // 数据库表前缀
            'prefix'      => 'ahsmp_',
        ];
    }

    /**
     * 生成子数据库配置文件
     * @param array $db 数据库信息
     * [
     *      'no' =>   序号,
     *      'key' =>  数据库标识
     *      'name' => 数据库名称
     * ]
     *
     * @return array
    */
    final protected function putChildDatabaseConfigFile($db){
        //读取原配置数据,因为TP Config::get()方法无法读取热数据,只有require查询新加的数据,避免循环操作时bug
        $config = $this->getChildDatabaseConfigFile();
        //新的子数据库配置
        $thisConfig = $this->getThisConfig($db);

        //新旧配置合并
        $config = array_merge($config,[
            $db['key'] => $thisConfig
        ]);

        //读取子模板,替换内容
        $configDir = Env::get('config_path');
        $childTpl = $configDir . self::CHILD_DATABASE_CONFIG_ITEM_TPL;
        if(!is_file($childTpl)){
            return [
                'result' => false,
                'msg' => '子数据库创建失败，子数据库模板文件不存在:item',
                'isNeedCreate' => true,
                'db' => $db
            ];
        }
        $childTplContent = file_get_contents($childTpl);
        $configStr = '';

        foreach ($config as $key => $item){
            //替换模板变量
            $itemContent = str_replace('{{KEY}}',$key,$childTplContent);
            $itemContent = str_replace('{{HOSTNAME}}',$item['hostname'],$itemContent);
            $itemContent = str_replace('{{NAME}}',$item['database'],$itemContent);
            $itemContent = str_replace('{{USERNAME}}',$item['username'],$itemContent);
            $itemContent = str_replace('{{PASSWORD}}',$item['password'],$itemContent);
            $itemContent = str_replace('{{HOSTPORT}}',$item['hostport'],$itemContent);
            $itemContent = str_replace('{{PREFIX}}',$item['prefix'],$itemContent);
            $configStr .= $itemContent;
        }

        //读取主模板,替换内容
        $tpl = $configDir . self::CHILD_DATABASE_CONFIG_TPL;
        if(!is_file($tpl)){
            return [
                'result' => false,
                'msg' => '子数据库创建失败，子数据库模板文件不存在',
                'isNeedCreate' => true,
                'db' => $db
            ];
        }
        $tplContent = file_get_contents($tpl);
        //替换模板变量
        $content = str_replace('{{CONFIG}}',$configStr,$tplContent);
        try{
            //生成新的子数据库配置文件
            if (!file_put_contents($configDir . self::CHILD_DATABASE_CONFIG_FILE,$content)) {
                return [
                    'result' => false,
                    'msg' => '子数据库创建失败，子数据库配置文件生成失败,请在服务器上对配置目录赋权限',
                    'isNeedCreate' => true,
                    'db' => $db
                ];
            }
        }catch (\Exception $e){
            return [
                'result' => false,
                'msg' => '子数据库创建失败，子数据库配置文件生成失败,请在服务器上对配置目录赋权限',
                'isNeedCreate' => true,
                'db' => $db
            ];
        }

        unset($config);
        return [
            'result' => true,
            'msg' => '子数据库配置文件生成成功',
            'isNeedCreate' => true,
            'db' => $db
        ];

    }

}